Skip to content

feat : 5주차 과제#205

Merged
dfdf0202 merged 5 commits intoLoopers-dev-lab:dfdf0202from
dfdf0202:volume-5
Mar 15, 2026
Merged

feat : 5주차 과제#205
dfdf0202 merged 5 commits intoLoopers-dev-lab:dfdf0202from
dfdf0202:volume-5

Conversation

@dfdf0202
Copy link

@dfdf0202 dfdf0202 commented Mar 13, 2026

📌 Summary

  • 배경: 배경: 10만건 상품 조회에서 4개 쿼리가 풀스캔 + filesort → 24~27ms 소요
  • 목표: 인덱스 설계 + Redis 캐시 도입으로 읽기 성능 최적화, 캐시 도입 시 레이어 아키텍처 유지
  • 결과: 인덱스로 93~96% 쿼리 개선, Redis 캐시(상세 1h, 목록 5m, 좋아요 카운터), 도메인 인터페이스로 캐시 관심사 격리

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: 상품 목록 6가지 정렬 중 4개가 type: ALL + Using filesort (풀테이블 스캔)
  • 문제(또는 리스크): 데이터 증가 시 조회 성능 선형 저하. 반복 조회에 대한 캐시 부재
  • 성공 기준(완료 정의): 모든 정렬 쿼리 95%+ 개선, 캐시 실패 시에도 서비스 정상 동작, Facade에 캐시 로직 미노출

선택지와 결정

인덱스 전략

  • 고려한 대안:

    • A: 3컬럼 복합 인덱스 1개 (brand_id, like_count, price) → 관리 편하지만 가격순 +61.3%에 그침
    • B: 2컬럼 복합 인덱스 2개 (brand_id, price) + (brand_id, like_count) → 양쪽 모두 95%+
  • 최종 결정: B. B-Tree 좌측 접두사 규칙 때문에 3컬럼은 중간 컬럼을 건너뛸 수 없음

  • 트레이드오프: 인덱스 1개 추가 vs 성능 34%p 차이 → 성능 우선

캐시 도구

  • 고려한 대안:
    • A: @Cacheable — 편리하지만 부분 갱신 불가, 캐시 실패 시 예외 전파
    • B: RedisTemplate 직접 구현 — 명시적 키/TTL 제어, graceful 실패 처리
  • 최종 결정: B. SOT는 DB, 좋아요 카운터 INCR/DECR 패턴은 @Cacheable로 불가
  • 트레이드오프: 코드량 증가 vs 예측 가능성

캐시 레이어 위치

  • 고려한 대안:
    • A: A: Facade에서 직접 캐시 접근 → 빠른 구현이지만 레이어 위반
    • B: 도메인 인터페이스(ProductCacheRepository) + infrastructure 구현 → 관심사 분리
  • 최종 결정: B. 캐시는 인프라 관심사, Facade는 유스케이스 조합만 담당
  • 트레이드오프: 코드량 증가 vs 예측 가능성
  • 추후 개선 여지: Caffeine L1 + Redis L2 멀티레벨 캐시

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈/도메인: product, favorite
  • 신규 추가:
    • ProductCacheRepository (도메인 인터페이스)
    • ProductCacheRepositoryImpl (Redis 구현체)
    • RedisCacheManager (공통 캐시 유틸)
    • ProductCacheDto, ProductListCacheDto (캐시 전용 DTO)
    • FindProductListReqDto (목록 조회 요청 DTO)
    • ProductLikeStatsEntity (좋아요 통계)
      -SortFilter enum (정렬 필터)
    • 인덱스 성능 테스트 2개
  • 제거/대체: Facade의 캐시 직접 의존 → Service 내부로 이동

주요 컴포넌트 책임

  • RedisCacheManager: Redis 읽기/쓰기/카운터 연산의 공통 래퍼. 모든 Redis 호출을 try-catch로 감싸서 장애 시 graceful 무시
  • ProductCacheRepository: 도메인 계층의 캐시 인터페이스. 상품 상세/목록/좋아요 카운터 캐시 계약 정의
  • ProductCacheRepositoryImpl: Redis 기반 구현체. 캐시 키 전략(product:{id}, product-list:first-page, product:{id}:likes)과 TTL 관리
  • ProductService: 캐시 로직 흡수. 캐시 hit/miss 분기, likeCount 증감(DB + 캐시 동시 처리)
  • ProductFacade: 유스케이스 조합만 담당. 캐시 존재를 모름
  • ProductEntity: @table(indexes) 4개 인덱스 선언

🔁 Flow Diagram

1. 상품 상세 조회 (Cache-Aside)

sequenceDiagram
  autonumber
  participant Client
  participant Controller as ProductV1Controller
  participant Facade as ProductFacade
  participant Service as ProductService
  participant Cache as ProductCacheRepository<br/>(Redis)
  participant DB as ProductCustomRepository<br/>(MySQL)

  Client->>Controller: GET /products/{id}
  Controller->>Facade: findProduct(loginId, password, productId)
  Facade->>Service: findProductDetail(productId)
  Service->>Cache: get(productId)

  alt Cache HIT
    Cache-->>Service: ProductItem (cached)
    Service->>Cache: getLikeCount(productId)
    Cache-->>Service: likeCount
    Service-->>Facade: item.withFavoriteCnt(likeCount)
  else Cache MISS
    Cache-->>Service: Optional.empty()
    Service->>DB: findProduct(productId)
    DB-->>Service: ProductItem
    Service->>Cache: put(productId, item, TTL=3600s)
    Service->>Cache: initLikeCount(productId, count)
    Service-->>Facade: item
  end

  Facade-->>Controller: FindProductResDto
  Controller-->>Client: ApiResponse<FindProductApiResDto>
Loading

2. 상품 목록 조회 (첫 페이지 캐시)

sequenceDiagram
  autonumber
  participant Client
  participant Controller as ProductV1Controller
  participant Facade as ProductFacade
  participant Service as ProductService
  participant Cache as Redis
  participant DB as MySQL

  Client->>Controller: GET /products?sort=LATEST&page=0
  Controller->>Facade: findProductList(req, pageable)
  Facade->>Service: findProductList(brandId, sortFilter, pageable)

  Service->>Service: isFirstPageLatest?<br/>(brandId==null && LATEST && page==0)

  alt 첫 페이지 최신순 + Cache HIT
    Service->>Cache: getFirstPage()
    Cache-->>Service: CachedPage
    loop 각 상품
      Service->>Cache: getLikeCount(productId)
      Cache-->>Service: likeCount
    end
    Service-->>Facade: Page<ProductItem>
  else Cache MISS 또는 다른 조건
    Service->>DB: findProductList(brandId, sortFilter, pageable)
    DB-->>Service: Page<ProductItem>
    opt 첫 페이지 최신순
      Service->>Cache: putFirstPage(items, TTL=300s)
      loop 각 상품
        Service->>Cache: initLikeCount(productId, count)
      end
    end
    Service-->>Facade: Page<ProductItem>
  end

  Facade-->>Controller: List<FindProductListResDto>
  Controller-->>Client: ApiResponse
Loading

3. 좋아요 토글 (DB + 캐시 동시 갱신)

sequenceDiagram
  autonumber
  participant Client
  participant Facade as FavoriteFacade
  participant FavSvc as FavoriteService
  participant ProdSvc as ProductService
  participant DB as MySQL
  participant Cache as Redis

  rect rgb(232, 245, 233)
    Note over Client,Cache: 좋아요 추가 (POST)
    Client->>Facade: addFavorite(loginId, password, productId)
    Facade->>FavSvc: addFavorite(command)
    FavSvc->>DB: existsByMemberIdAndProductId?

    alt 이미 존재
      FavSvc-->>Facade: false (무시)
    else 신규
      FavSvc->>DB: save(Favorite)
      FavSvc-->>Facade: true
      Facade->>ProdSvc: increaseLikeCount(productId)
      ProdSvc->>DB: UPDATE product SET like_count + 1
      ProdSvc->>Cache: INCR product:{id}:likes
    end
  end

  rect rgb(255, 235, 238)
    Note over Client,Cache: 좋아요 취소 (DELETE)
    Client->>Facade: deleteFavorite(loginId, password, productId)
    Facade->>FavSvc: delete(command)
    FavSvc->>DB: DELETE favorite
    Facade->>ProdSvc: decreaseLikeCount(productId)
    ProdSvc->>DB: UPDATE product SET like_count - 1
    ProdSvc->>Cache: DECR product:{id}:likes
  end
Loading

4. 캐시 장애 시 Graceful Degradation

flowchart TD
  A[Redis 요청] --> B{Redis 정상?}
  B -->|Yes| C[캐시 데이터 반환]
  B -->|No| D[Exception 발생]
  D --> E["log.warn() 경고 로깅"]
  E --> F[Optional.empty 반환]
  F --> G[DB Fallback 조회]
  G --> H[정상 응답]
  C --> H

  style D fill:#fff3e0,stroke:#f57c00
  style E fill:#fff3e0,stroke:#f57c00
  style F fill:#fff3e0,stroke:#f57c00
  style G fill:#e3f2fd,stroke:#1976d2
  style H fill:#e8f5e9,stroke:#388e3c
Loading

5. 재고 감소 시 캐시 무효화

sequenceDiagram
  autonumber
  participant Service as ProductService
  participant DB as MySQL
  participant Cache as Redis

  Service->>DB: decreaseStock(productId, qty)
  DB-->>Service: updatedRows

  alt updatedRows == 0
    Service->>Service: throw CoreException("재고 부족")
  else 성공
    Service->>Cache: evict(product:{id})
    Service->>Cache: evictFirstPage()
    Service->>DB: findById(productId)
    DB-->>Service: Product
  end
Loading

변경 목적

약 100k 상품에 대한 4가지 정렬 방식에서 full table scan 및 filesort 발생으로 인한 읽기 성능 저하(24~27ms)를 해결하기 위해 MySQL 인덱스(brand_id+price, brand_id+like_count 복합 인덱스)와 Redis 캐싱(상세 1시간, 첫 페이지 5분 TTL)을 도입하여 DB를 소스 오브 트루스로 유지하면서 캐시 전용 인터페이스로 계층 간 경계 보존.

핵심 변경점

ProductCacheRepository 도메인 인터페이스와 RedisCacheManager 기반 ProductCacheRepositoryImpl 추가로 캐시 처리를 ProductService에 통합; Product.reconstruct에 likeCount 파라미터 추가; FavoriteFacade에서 좋아요 추가/제거 시 ProductService의 increaseLikeCount/decreaseLikeCount 호출; ProductEntity에 4개 인덱스 선언; stock 감소 시 상품 상세 및 첫 페이지 캐시 자동 무효화; ProductFacade.findProductList 시그니처를 FindProductListReqDto로 통일하고 favoriteService.getFavoriteProductIds 호출로 favorite 정보 별도 관리.

리스크/주의사항

Redis 장애 시 try-catch로 처리되어 DB 폴백하지만 좋아요 카운트가 DB와 불일치할 수 있음; deleteFavorite는 항상 decreaseLikeCount를 호출하지만 addFavorite는 조건부(added==true)로만 증가시키는데, 중복 즐겨찾기 시도 시 카운트 불균형 가능성 확인 필요; 첫 페이지 캐시 5분 TTL로 인한 데이터 최신성 요구사항 검증 필요; stock decrease 외 다른 상품 수정 작업(update 등)에서 캐시 무효화 누락 가능성.

테스트/검증 방법

ProductIndexPerformanceTest와 ProductQueryPerformanceTest로 인덱스 및 쿼리 성능 개선 95% 이상 달성 확인; RedisCacheManagerTest와 ProductCacheRepositoryImplTest로 캐시 기본 동작 및 Redis 실패 처리 검증; 기존 ProductServiceTest, FavoriteFacadeTest, ProductFacadeTest 업데이트로 캐시 경로 및 좋아요 카운트 동기화 확인; Redis 장애 상황에서 DB 폴백 정상 동작 테스트 필요.

@coderabbitai
Copy link

coderabbitai bot commented Mar 13, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 40ee69d4-8de8-49a6-89fd-7bd348631b55

📥 Commits

Reviewing files that changed from the base of the PR and between 9205bec and 6326152.

📒 Files selected for processing (16)
  • apps/commerce-api/src/main/java/com/loopers/domain/favorite/service/FavoriteService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java
  • apps/commerce-api/src/main/java/com/loopers/support/enums/SortFilter.java
  • apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/favorite/FavoriteFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/product/cache/ProductCacheServiceTest.java
  • build.gradle.kts
  • gradle.properties

📝 Walkthrough

Walkthrough

상품 좋아요 기능을 도입하며 좋아요 카운트를 상품 엔티티에 역정규화하고 Redis 기반 캐싱 계층을 추가한다. ProductFacade 메서드 시그니처를 DTO 기반으로 변경하고 FavoriteService를 boolean 반환으로 수정하여 성공 여부를 추적한다.

Changes

Cohort / File(s) Summary
Product 엔티티 및 모델
apps/commerce-api/src/main/java/com/loopers/domain/product/model/Product.java, ProductItem.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductEntity.java
likeCount 필드 추가, ProductItem에 withIsFavorite/withFavoriteCnt 메서드 추가, ProductEntity에 인덱스 추가.
Product 서비스 및 리포지토리
apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java, ProductCustomRepository.java, ProductRepository.java, ProductJpaRepository.java, ProductRepositoryImpl.java, ProductCustomRepositoryImpl.java
findProductList/findProduct 메서드에서 memberId 파라미터 제거, increaseLikeCount/decreaseLikeCount 메서드 추가, 캐싱 로직 통합.
Favorite 서비스 및 리포지토리
apps/commerce-api/src/main/java/com/loopers/domain/favorite/service/FavoriteService.java, FavoriteRepository.java, FavoriteJpaRepository.java, FavoriteRepositoryImpl.java, FavoriteEntity.java
addFavorite 반환 타입을 void에서 boolean으로 변경, getFavoriteProductIds 메서드 추가, 쿼리 메서드 추가, 인덱스 추가.
Favorite Facade
apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java
addFavorite 반환값 검사 후 productService.increaseLikeCount 조건부 호출, deleteFavorite에서 항상 decreaseLikeCount 호출.
Product Facade 및 DTO
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java, FindProductListReqDto.java, FindProductResDto.java
findProductList 메서드 시그니처 변경 (FindProductListReqDto 수용), favoriteService 통합, FindProductResDto를 ProductItem 기반으로 변경.
Product 캐시 인프라
apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java, ProductCacheRepositoryImpl.java, ProductCacheDto.java, ProductListCacheDto.java
Redis 기반 캐싱 인터페이스 및 구현체 추가, 개별 상품, 첫 페이지, 좋아요 카운트 캐싱 기능.
Redis 캐시 매니저
apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java
범용 Redis 캐시 관리 컴포넌트 추가 (JSON 직렬화, TTL 지원, 원자적 숫자 연산).
기타 변경
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java, SortFilter.java, build.gradle.kts, gradle.properties
ProductV1Controller 호출 방식 변경, SortFilter.toOrderSpecifiers 메서드 변경, testcontainers 추가.
테스트 추가 및 수정
apps/commerce-api/src/test/java/com/loopers/.../FavoriteFacadeTest.java, ProductFacadeTest.java, ProductServiceTest.java, ProductTest.java, BrandFacadeTest.java, OrderFacadeTest.java, RedisCacheManagerTest.java, ProductCacheServiceTest.java, ProductIndexPerformanceTest.java, ProductQueryPerformanceTest.java
Product.reconstruct 시그니처 변경에 대응, 캐싱 로직 테스트, 좋아요 기능 검증, 성능 테스트 추가.

Sequence Diagram

sequenceDiagram
    participant Client
    participant ProductFacade
    participant ProductService
    participant ProductCacheRepository
    participant Redis as Redis Cache
    participant ProductRepository
    participant Database

    Client->>ProductFacade: findProductList(req, pageable)
    activate ProductFacade
    ProductFacade->>ProductService: findProductList(brandId, sortFilter, pageable)
    activate ProductService
    
    alt 첫 페이지 & LATEST 정렬 & 캐시 존재
        ProductService->>ProductCacheRepository: getFirstPage()
        activate ProductCacheRepository
        ProductCacheRepository->>Redis: GET product-list:first-page
        Redis-->>ProductCacheRepository: CachedPage
        ProductCacheRepository-->>ProductService: Optional<CachedPage>
        deactivate ProductCacheRepository
        ProductService-->>ProductFacade: Page<ProductItem>
    else 첫 페이지 & LATEST 정렬 & 캐시 미스
        ProductService->>ProductRepository: findProductList(brandId, sortFilter, pageable)
        activate ProductRepository
        ProductRepository->>Database: SELECT ...
        Database-->>ProductRepository: List<ProductItem>
        ProductRepository-->>ProductService: Page<ProductItem>
        deactivate ProductRepository
        
        ProductService->>ProductCacheRepository: putFirstPage(items, totalElements)
        activate ProductCacheRepository
        ProductCacheRepository->>Redis: SET product-list:first-page (TTL: 300s)
        Redis-->>ProductCacheRepository: OK
        deactivate ProductCacheRepository
        
        ProductService-->>ProductFacade: Page<ProductItem>
    else 다른 정렬 또는 브랜드 필터
        ProductService->>ProductRepository: findProductList(brandId, sortFilter, pageable)
        activate ProductRepository
        ProductRepository->>Database: SELECT ...
        Database-->>ProductRepository: List<ProductItem>
        ProductRepository-->>ProductService: Page<ProductItem>
        deactivate ProductRepository
        ProductService-->>ProductFacade: Page<ProductItem>
    end
    deactivate ProductService
    
    ProductFacade->>ProductFacade: 좋아요 상태 조회 및 매핑
    ProductFacade-->>Client: Page<FindProductListResDto>
    deactivate ProductFacade
Loading
sequenceDiagram
    participant Client
    participant FavoriteFacade
    participant FavoriteService
    participant ProductService
    participant ProductRepository
    participant ProductCacheRepository
    participant Database
    participant Redis as Redis Cache

    Client->>FavoriteFacade: addFavorite(command)
    activate FavoriteFacade
    FavoriteFacade->>FavoriteService: addFavorite(command)
    activate FavoriteService
    FavoriteService->>Database: INSERT favorite
    alt 성공
        Database-->>FavoriteService: true
    else 중복 또는 제약 위반
        Database-->>FavoriteService: false
    end
    FavoriteService-->>FavoriteFacade: boolean (added)
    deactivate FavoriteService
    
    alt added == true
        FavoriteFacade->>ProductService: increaseLikeCount(productId)
        activate ProductService
        ProductService->>ProductRepository: increaseLikeCount(productId)
        activate ProductRepository
        ProductRepository->>Database: UPDATE product SET likeCount = likeCount + 1
        Database-->>ProductRepository: success
        ProductRepository-->>ProductService: int (rows updated)
        deactivate ProductRepository
        
        ProductService->>ProductCacheRepository: incrementLikeCount(productId)
        activate ProductCacheRepository
        ProductCacheRepository->>Redis: INCR product:productId:likes
        Redis-->>ProductCacheRepository: OK
        deactivate ProductCacheRepository
        
        ProductService-->>FavoriteFacade: void
        deactivate ProductService
    end
    
    FavoriteFacade-->>Client: void
    deactivate FavoriteFacade
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

검토 관점:

  • 운영 관점: Redis 캐싱 계층 도입으로 인한 캐시 일관성 문제 위험. 좋아요 추가/삭제 시 데이터베이스와 Redis 캐시 모두 업데이트되어야 하는데, 부분 실패 시나리오 처리 불충분. 예: ProductRepository.increaseLikeCount 성공 후 cache.incrementLikeCount 실패 시 데이터 불일치 발생 가능.

  • 성능/스케일링: denormalized likeCount로 인해 좋아요 조회 성능 향상되지만, 인덱스 효율성 검증 필요. ProductIndexPerformanceTest와 ProductQueryPerformanceTest가 추가되었으나, 실제 운영 환경의 대규모 데이터셋에서 검증 필수.

  • 테스트: 캐시 eviction 로직이 여러 곳에 산재. ProductService.decreaseStockAtomic에서 캐시 evict 호출하는 등 부작용이 명시적이지 않음. 캐시 일관성을 보장하는 통합 테스트 추가 필요.

Possibly related PRs

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning PR 제목이 '5주차 과제'로 너무 모호하여 이 PR의 핵심 목표를 파악하기 어렵다. 제목을 구체적으로 수정하여 핵심 변경사항을 명확히 표현한다. 예: 'feat: 상품 조회 성능 최적화를 위한 인덱스 설계 및 Redis 캐시 도입'
Docstring Coverage ⚠️ Warning Docstring coverage is 2.68% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed PR 설명이 목표, 설계 의사결정, 아키텍처, 흐름도 등을 포함하여 요구 템플릿의 모든 핵심 섹션을 완벽하게 충족한다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/commerce-api/src/main/java/com/loopers/domain/product/model/Product.java (1)

39-49: ⚠️ Potential issue | 🔴 Critical

reconstruct 공개 시그니처 변경으로 현재 테스트 컴파일이 깨진다.

Line 39에서 7번째 인자를 필수로 만들었는데, 제공된 컨텍스트 기준으로 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java:52-65, apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java:82-96, apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java:68-70가 아직 6개 인자로 호출하고 있다. 운영 관점에서는 머지 전 CI가 컴파일 단계에서 중단되는 블로커다. 수정안은 이 파일에 6개 인자 오버로드를 남겨 likeCount = 0L로 위임하거나, 같은 PR에서 모든 호출부를 7개 인자로 일괄 갱신하는 것이다. 추가 테스트로 위 3개 테스트 픽스처를 갱신하고, 기본 재구성 시 likeCount가 0으로 채워지는 케이스를 하나 더 두는 것이 안전하다.

수정 예시
+    public static Product reconstruct(Long id, Long brandId, String name, int price, int stock, DisplayStatus displayStatus) {
+        return reconstruct(id, brandId, name, price, stock, displayStatus, 0L);
+    }
+
     public static Product reconstruct(Long id, Long brandId, String name, int price, int stock, DisplayStatus displayStatus, long likeCount) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/model/Product.java`
around lines 39 - 49, The public signature of Product.reconstruct was changed to
require a seventh likeCount parameter and broke existing tests; either restore a
6-arg overload or update all callers. Fix by adding a new overloaded static
method Product.reconstruct(Long id, Long brandId, String name, int price, int
stock, DisplayStatus displayStatus) that delegates to the 7-arg
Product.reconstruct(...) with likeCount = 0L, or alternatively update the three
test call sites (ProductServiceTest, ProductTest, ProductFacadeTest) to pass the
new likeCount argument and add a new test asserting default reconstruct behavior
sets likeCount to 0L. Ensure you modify the Product.reconstruct method(s) and
tests referencing reconstruct accordingly.
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java (1)

33-44: ⚠️ Potential issue | 🔴 Critical

displayStatus 프로젝션 타입이 ProductItem 생성자 파라미터와 맞지 않는다.

33-44번 줄과 63-74번 줄에서 productEntity.displayStatus(enum 타입 DisplayStatus)를 그대로 constructor projection에 전달하고 있으나, ProductItem 레코드의 7번째 파라미터는 String displayStatus다. QueryDSL의 Projections.constructor()는 호환성 검사를 통해 일치하는 생성자를 찾는데, EnumPath<DisplayStatus>String 파라미터는 호환되지 않아 목록/상세 조회 실행 시 ExpressionException이 발생한다. 운영 관점에서는 캐시 미스 fallback 연쇄 실패까지 이어질 수 있다. productEntity.displayStatus.stringValue()를 사용하여 쿼리 레벨에서 enum을 String으로 명시적으로 변환해야 한다.

수정 예시
                 .select(Projections.constructor(ProductItem.class,
                         productEntity.id,
                         productEntity.name,
                         brandEntity.id,
                         brandEntity.name,
                         productEntity.price,
                         productEntity.stock,
-                        productEntity.displayStatus,
+                        productEntity.displayStatus.stringValue(),
                         productEntity.likeCount,
                         Expressions.asBoolean(false)
                 ))
@@
                 .select(Projections.constructor(ProductItem.class,
                         productEntity.id,
                         productEntity.name,
                         brandEntity.id,
                         brandEntity.name,
                         productEntity.price,
                         productEntity.stock,
-                        productEntity.displayStatus,
+                        productEntity.displayStatus.stringValue(),
                         productEntity.likeCount,
                         Expressions.asBoolean(false)
                 ))

추가로 QueryDSL 기반 repository integration test를 작성하여 목록 조회와 상세 조회 모두 ProductItem 생성이 정상 동작하는지 검증해야 한다.

또한 적용됨: 63-74번 줄

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java`
around lines 33 - 44, The constructor projection is passing
productEntity.displayStatus (EnumPath<DisplayStatus>) into ProductItem's
constructor which expects a String, causing ExpressionException; update both
occurrences where Projections.constructor(ProductItem.class, ...) is used to
replace productEntity.displayStatus with
productEntity.displayStatus.stringValue() so the query returns a String that
matches ProductItem's 7th parameter, and add QueryDSL-based repository
integration tests asserting that list and detail queries produce valid
ProductItem instances (no ExpressionException) for both active and non-active
statuses.
🧹 Nitpick comments (13)
apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java (3)

124-126: System.out.println 대신 로깅 프레임워크 사용 권장

테스트 결과를 System.out.println으로 출력하면 CI 환경에서 로그 필터링이 어렵고, 테스트 실행 시 불필요한 출력이 발생한다.

SLF4J Logger를 사용하거나, 성능 측정 결과를 별도 파일로 저장하는 방식을 고려할 수 있다.

Also applies to: 161-163, 198-200

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java`
around lines 124 - 126, Replace the System.out.println calls in
ProductQueryPerformanceTest with an SLF4J Logger: add a private static final
Logger (LoggerFactory.getLogger(ProductQueryPerformanceTest.class)) to the class
and change each println (the blocks around the "=== 방식 A (비정규화) ===" output and
the other occurrences at the mentioned spots) to logger.info(...) so test output
goes through the logging framework and can be filtered by CI; ensure the
formatted messages remain the same and adjust imports to include
org.slf4j.Logger and org.slf4j.LoggerFactory.

23-94: 테스트 데이터 정리 전략 부재

@BeforeAll로 데이터를 생성하지만 @AfterAll로 정리하지 않는다. 동일 테스트 컨테이너 인스턴스를 재사용하는 환경에서 데이터 충돌이 발생할 수 있다.

또한 setUp() 내에서 트랜잭션 롤백 없이 커밋하므로, 테스트 실패 시에도 데이터가 남는다.

🔧 데이터 정리 추가 예시
`@AfterAll`
void tearDown() {
    EntityManager em = entityManagerFactory.createEntityManager();
    try {
        em.getTransaction().begin();
        em.createNativeQuery("DELETE FROM favorite").executeUpdate();
        em.createNativeQuery("DELETE FROM product_like_stats").executeUpdate();
        em.createNativeQuery("DELETE FROM product").executeUpdate();
        em.createNativeQuery("DELETE FROM member").executeUpdate();
        em.createNativeQuery("DELETE FROM brand").executeUpdate();
        em.getTransaction().commit();
    } finally {
        em.close();
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java`
around lines 23 - 94, The test data created in setUp() (method setUp annotated
with `@BeforeAll`) is never cleaned and commits unconditionally, so add a
complementary `@AfterAll` method (e.g., tearDown) that opens an EntityManager,
begins a transaction, deletes rows from favorite, product_like_stats, product,
member, and brand (in that or safe FK order), commits and closes the EM in a
finally block; also make setUp() robust by wrapping each transactional block in
try/catch and calling em.getTransaction().rollback() on exceptions (and closing
the EntityManager in finally) so partial failures don’t leave data behind.

66-72: RAND() 사용으로 인한 비결정적 테스트 데이터 문제

RAND() < 0.05 조건으로 좋아요 데이터를 생성하면 매 실행마다 favorite 개수가 달라진다. 이로 인해:

  1. 성능 측정 결과의 재현성이 없다
  2. 동일 조건에서 비교가 불가능하다
  3. CI 환경에서 플래키 테스트로 이어질 수 있다

결정적 데이터 생성 방식으로 변경이 필요하다.

🔧 결정적 데이터 생성 예시
-        // favorite: 멤버 10명 x 상품 랜덤 좋아요 (약 5%)
-        em.createNativeQuery(
-                "INSERT INTO favorite (member_id, product_id, created_at, updated_at) " +
-                "SELECT m.id, p.id, NOW(), NOW() " +
-                "FROM member m CROSS JOIN product p " +
-                "WHERE RAND() < 0.05")
-            .executeUpdate();
+        // favorite: 멤버 10명 x 상품 좋아요 (product_id % 20 == 0 조건으로 약 5% 생성)
+        em.createNativeQuery(
+                "INSERT INTO favorite (member_id, product_id, created_at, updated_at) " +
+                "SELECT m.id, p.id, NOW(), NOW() " +
+                "FROM member m CROSS JOIN product p " +
+                "WHERE MOD(p.id, 20) = 0")
+            .executeUpdate();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java`
around lines 66 - 72, The insert uses RAND() causing non-deterministic
favorites; change the WHERE clause in the em.createNativeQuery(...) favorite
insert to a deterministic predicate (e.g., replace "WHERE RAND() < 0.05" with a
modulus-based condition such as "WHERE MOD(m.id + p.id, 20) = 0" or another
fixed hash/row-number expression) so the favorite generation is reproducible
across runs; update the INSERT statement inside the createNativeQuery call that
builds favorites to use this deterministic filter.
apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java (1)

28-30: 반환값 처리에 대한 명세 권장

int 반환값이 영향받은 행 수를 의미한다면, 0 반환 시(상품 미존재) 호출부에서 적절히 처리해야 한다. 기존 decreaseStock과 동일한 패턴이므로 일관성은 유지되나, 메서드 Javadoc에 반환값 의미와 실패 케이스를 명시하면 유지보수에 도움이 된다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java`
around lines 28 - 30, Add Javadoc to the ProductRepository methods
increaseLikeCount(Long id) and decreaseLikeCount(Long id) that explicitly states
the int return value represents the number of affected rows, what a 0 return
means (e.g. product not found / no update), and any error/exception behavior to
mirror the existing decreaseStock contract; update the method comments for
consistency with decreaseStock so callers know to handle a 0 return as a
failure/no-op.
apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/FavoriteJpaRepository.java (1)

13-13: 대량 productIds 리스트에 대한 IN 절 처리 주의

productIds 리스트가 매우 클 경우 IN 절의 파라미터 제한(Oracle 1000개, MySQL 성능 저하)에 주의해야 한다. 상품 목록이 페이지당 제한되어 있다면 문제없지만, 확장 시 배치 분할 처리가 필요할 수 있다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/FavoriteJpaRepository.java`
at line 13, The repository method findByMemberIdAndProductIdIn in
FavoriteJpaRepository must guard against very large productIds lists (DB IN
limits/perf); change callers or add a new repository helper that splits
productIds into configurable chunks (e.g., <=1000) and executes repeated
findByMemberIdAndProductIdIn calls, aggregating and deduplicating results, or
implement an alternative approach (temporary table/join or batch native query)
if available; ensure chunk size is configurable and document/update usages to
call the new batched helper instead of passing an unbounded list directly to
findByMemberIdAndProductIdIn.
apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java (1)

16-16: CachedPageitems 리스트 방어적 복사 권장

List<ProductItem>이 외부에서 변경될 수 있다. record는 불변이지만 내부 컬렉션은 그렇지 않다. List.copyOf(items) 또는 Collections.unmodifiableList()로 감싸는 것이 안전하다.

♻️ 선택적 개선안
-    record CachedPage(List<ProductItem> items, long totalElements) {}
+    record CachedPage(List<ProductItem> items, long totalElements) {
+        public CachedPage {
+            items = List.copyOf(items);
+        }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java`
at line 16, CachedPage's items list is mutable externally; change the record to
defensively copy/wrap the list so the internal collection is immutable. Update
the record CachedPage(List<ProductItem> items, long totalElements) to assign
this.items = List.copyOf(items) (or
Collections.unmodifiableList(List.copyOf(items))) in a compact constructor (or
use a static factory) so stored items cannot be modified from outside; reference
CachedPage and ProductItem when applying the change.
apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/entity/FavoriteEntity.java (1)

11-13: 인덱스 추가 적절함 - 운영 반영 시 주의

countByProductId 쿼리 성능 향상을 위한 productId 단일 컬럼 인덱스 추가는 적절하다. 다만 운영 DB에 대량 데이터가 있는 경우, 인덱스 생성 시 테이블 락 또는 성능 저하가 발생할 수 있다.

MySQL 8.0+의 경우 ALGORITHM=INPLACE, LOCK=NONE 옵션을 사용한 온라인 DDL이 가능하므로, 마이그레이션 스크립트에서 이를 고려해야 한다.

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/entity/FavoriteEntity.java`
around lines 11 - 13, The new single-column index idx_favorite_product_id on the
FavoriteEntity (table "favorite") to speed up countByProductId is fine, but when
adding it in migrations for production with large data you must create the index
using MySQL online DDL options to avoid table locks; update the migration that
creates idx_favorite_product_id to use "ALTER TABLE favorite ADD INDEX
idx_favorite_product_id (productId) ALGORITHM=INPLACE, LOCK=NONE" (or the
equivalent in your migration tool), and include a rollback/remove index step and
a note to validate MySQL version (>=8.0) before applying.
apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java (1)

30-41: 제네릭 타입 역직렬화 개선 - TypeReference 오버로드 추가 검토

현재 코드는 ProductListCacheDto.class 같은 구체적 타입을 사용하기 때문에 Jackson이 필드 메타데이터를 통해 중첩된 List<ProductCacheDto>를 올바르게 역직렬화한다. 따라서 실제 ClassCastException은 발생하지 않는다.

다만 향후 더 복잡한 제네릭 타입이나 Map 기반 응답을 처리할 때를 대비하여, TypeReference<T> 오버로드 메서드를 추가하는 것을 검토할 수 있다. 이는 필수 사항은 아니나 타입 안정성을 높이는 개선안이 될 수 있다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java`
around lines 30 - 41, 현재 RedisCacheManager의 public <T> Optional<T> get(String
key, Class<T> type) 메서드는 단일 Class 기반 역직렬화만 지원하므로 제네릭 복합 타입을 안전하게 처리하려면
TypeReference<T> 오버로드를 추가하세요: RedisCacheManager에 public <T> Optional<T>
get(String key, com.fasterxml.jackson.core.type.TypeReference<T> typeRef) 메서드를
추가하고 내부에서 readTemplate.opsForValue().get(key)로 JSON을 읽은 뒤
objectMapper.readValue(json, typeRef)로 역직렬화하며 기존 get(Class<T>)와 동일한 null 처리 및 예외
로깅(log.warn("Redis GET 실패 - key: {}", key, e)) 동작을 재사용하도록 구현하세요; 기존
get(Class<T>)는 그대로 유지해서 하위 호환성을 보장하세요.
apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/impl/FavoriteRepositoryImpl.java (1)

47-52: productIds 빈 입력 방어를 Repository 레이어에도 추가하는 것이 안전하다.

운영 관점에서 Line 47-48은 빈/널 목록 입력 시 불필요한 쿼리로 DB 부하가 증가하거나 구현체별 IN () 처리 차이로 예외가 발생할 수 있다.
수정안으로 메서드 초기에 빈 입력을 즉시 반환하도록 가드를 추가하는 것이 좋다.
추가 테스트로 productIds == null/빈 리스트 입력 시 빈 결과를 반환하는 케이스를 추가하는 것이 좋다.

♻️ 제안 수정안
`@Override`
public List<Favorite> findByMemberIdAndProductIds(Long memberId, List<Long> productIds) {
+    if (productIds == null || productIds.isEmpty()) {
+        return List.of();
+    }
     return favoriteJpaRepository.findByMemberIdAndProductIdIn(memberId, productIds)
         .stream()
         .map(FavoriteEntity::toModel)
         .toList();
}

As per coding guidelines **/*Repository*.java: "쿼리 조건 누락/과다 조회, 정렬/인덱스 활용 가능성, 대량 데이터에서의 병목을 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/impl/FavoriteRepositoryImpl.java`
around lines 47 - 52, Add a defensive guard at the start of
FavoriteRepositoryImpl.findByMemberIdAndProductIds to immediately return an
empty List when productIds is null or empty, avoiding unnecessary DB calls and
potential IN () errors; update the method that currently calls
favoriteJpaRepository.findByMemberIdAndProductIdIn to check productIds.isEmpty()
(and null) before invoking the repository, and add unit tests for both null and
empty productIds cases to assert an empty result is returned.
apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java (3)

65-69: 첫 페이지 캐시 저장 시 N회의 Redis 호출이 발생한다.

result.getContent().forEach(item -> productCacheRepository.initLikeCount(...))에서 페이지 내 모든 아이템에 대해 개별 Redis SET 호출이 발생한다. 기본 페이지 크기가 20이면 20회의 네트워크 왕복이 발생한다.

수정안: Redis MSET 또는 파이프라인을 사용하여 배치 처리한다.

// ProductCacheRepository interface에 추가
void initLikeCounts(Map<Long, Long> productIdToCount);

// RedisCacheManager에서 pipeline 또는 MSET 사용

단, 현재 트래픽 수준에서 병목이 아니라면 추후 개선 사항으로 분류해도 무방하다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java`
around lines 65 - 69, The current loop in ProductService
(result.getContent().forEach -> productCacheRepository.initLikeCount(...))
causes N separate Redis SETs; change ProductCacheRepository to add a batched API
(e.g., initLikeCounts(Map<Long,Long>) or initLikeCounts(List<Pair<Long,Long>>)
and implement it in RedisCacheManager using MSET or a pipelined set to perform
the writes in a single round-trip; update ProductService (where
isFirstPageLatest calls productCacheRepository.putFirstPage(...)) to build the
id->count map from result.getContent() and call the new batched method instead
of per-item initLikeCount.

138-147: resolveLikeCount()에서 캐시 미스 시 DB 조회와 캐시 초기화의 race condition이 존재한다.

ProductCacheRepositoryImpl.initLikeCount()의 무조건적 SET 문제가 여기서도 적용된다. 캐시 미스로 DB 조회 후 initLikeCount() 호출 사이에 다른 스레드가 INCR/DECR을 수행하면 덮어쓰기가 발생한다.

ProductCacheRepositoryImpl 리뷰에서 제안한 setCountIfAbsent 패턴을 적용하면 이 문제가 해결된다.

추가로, 캐시 미스 시 DB 폴백이 빈번하게 발생하는지 모니터링을 위한 로깅 추가를 권장한다:

 private long resolveLikeCount(Long productId) {
     return productCacheRepository.getLikeCount(productId)
             .orElseGet(() -> {
+                log.debug("좋아요 캐시 미스, DB 폴백 - productId: {}", productId);
                 Product product = productRepository.findById(productId)
                         .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다."));
                 long count = product.getLikeCount();
                 productCacheRepository.initLikeCount(productId, count);
                 return count;
             });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java`
around lines 138 - 147, resolveLikeCount currently reads DB and then calls
productCacheRepository.initLikeCount(productId, count), which can race with
concurrent INCR/DECR and overwrite updated counts; switch to a compare-and-set
or "setCountIfAbsent" pattern in ProductCacheRepository (use a new method like
setCountIfAbsent(productId, count) instead of initLikeCount) so the cache is
only initialized when absent, and log a cache-miss event when falling back to
the DB to aid monitoring; update resolveLikeCount to call
productCacheRepository.setCountIfAbsent(productId, count) after reading
product.getLikeCount() and emit a short debug/info log indicating a cache miss
for productId.

108-120: decreaseStockAtomic()에서 findById가 2회 호출되어 불필요한 DB 조회가 발생한다.

Line 109에서 존재 확인 후 Line 118에서 다시 조회한다. decreaseStock()이 0을 반환하면 재고 부족 또는 상품 미존재 모두 해당될 수 있으므로 구분이 필요하다는 의도로 보이나, 현재 구조에서는 존재하지 않는 상품에 대해 decreaseStock이 0을 반환해도 "재고 부족" 에러가 발생한다.

현재 로직이 의도된 것이라면 주석을 추가하고, 그렇지 않다면 단일 조회로 최적화할 수 있다.

단일 조회 최적화 (선택적)
 public Product decreaseStockAtomic(Long productId, int quantity) {
-    productRepository.findById(productId)
-            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다."));
-
     int updatedRows = productRepository.decreaseStock(productId, quantity);
     if (updatedRows == 0) {
-        throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
+        // 상품 미존재 또는 재고 부족 구분을 위해 조회
+        productRepository.findById(productId)
+            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다."));
+        throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
     }
     productCacheRepository.evict(productId);
     productCacheRepository.evictFirstPage();
     return productRepository.findById(productId)
             .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다."));
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java`
around lines 108 - 120, decreaseStockAtomic currently calls
productRepository.findById twice and can misclassify a missing product as "out
of stock"; fix by loading the product once with productRepository.findById in
decreaseStockAtomic (throw NOT_FOUND if absent), check in-memory
product.getStock() < quantity and throw BAD_REQUEST if insufficient, then call
productRepository.decreaseStock(productId, quantity), evict caches via
productCacheRepository.evict(...) / evictFirstPage(), and return the updated
product (update the in-memory product's stock or re-load only if necessary);
alternatively, if you prefer to keep optimistic DB update, replace the first
findById with existsById before calling decreaseStock and only re-query when
updatedRows==0 to distinguish NOT_FOUND vs BAD_REQUEST.
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java (1)

87-92: toItem()에서 favoriteCnt를 0L로 하드코딩하는 것은 의도된 설계이나 주석이 필요하다.

ProductCacheDtofavoriteCnt를 포함하지 않아 toItem()에서 0L로 초기화하고, 호출부에서 resolveLikeCount()로 별도 조회하여 withFavoriteCnt()로 덮어쓰는 구조이다. 이는 좋아요 수의 atomic INCR/DECR을 위한 분리 설계로 보이나, 코드만 보고는 파악이 어렵다.

수정안: 해당 의도를 주석으로 명시하여 유지보수성을 높인다.

 private ProductItem toItem(ProductCacheDto dto) {
+    // favoriteCnt는 별도 Redis 키로 관리하여 atomic increment를 지원한다.
+    // 호출부에서 resolveLikeCount()로 실제 값을 조회하여 설정한다.
     return new ProductItem(
             dto.id(), dto.name(), dto.brandId(), dto.brandName(),
             dto.price(), dto.stock(), dto.displayStatus(), 0L, false
     );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java`
around lines 87 - 92, toItem currently hardcodes favoriteCnt to 0L which is
intentional because ProductCacheDto lacks that field and callers call
resolveLikeCount() then withFavoriteCnt() to set the true value; add a brief
clarifying comment inside the toItem method noting this design (favorite count
is stored/updated separately for atomic INCR/DECR, initialized to 0 here and
overridden by resolveLikeCount()/withFavoriteCnt()), referencing
ProductCacheDto, toItem, resolveLikeCount, and withFavoriteCnt so future
maintainers understand the separation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java`:
- Around line 27-30: The current flow in FavoriteFacade (after calling
favoriteService.addFavorite(command)) calls
productService.increaseLikeCount(product.getId()) but swallows Redis cache
failures inside that call; change the logic so FavoriteFacade detects
cache-update failures from productService.increaseLikeCount (e.g., propagate a
specific exception or return a result/status), and handle it by either
triggering a retry path or marking the operation as partially failed and
enqueuing a background/sync job; specifically update
productService.increaseLikeCount to surface cache errors (throw
CacheUpdateException or return a boolean/status), then in FavoriteFacade check
that result and on failure: (a) attempt a limited retry, or (b) record an async
reconciliation task (or flag) to resync cache and emit an alert/event so the DB
remains authoritative until cache is reconciled.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/dto/FindProductListReqDto.java`:
- Around line 5-9: FindProductListReqDto currently holds a plain-text password
field which will be included in the compiler-generated toString(); remove
sensitive data by eliminating the password component from the record signature
(pass only an authenticated memberId/principal into controllers) or, as a
short-term mitigation, override FindProductListReqDto#toString() to mask or omit
the password field so it never appears in logs; update callers of
FindProductListReqDto to stop supplying raw credentials and add tests asserting
toString() does not contain the password and that product listing still works
when password is removed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/favorite/service/FavoriteService.java`:
- Around line 51-53: The null-check currently only guards memberId but will
throw NPE when productIds is null; in FavoriteService (the method containing the
lines checking memberId and productIds) add a null-guard for productIds (treat
null as empty) so the method returns an empty Set instead of NPE (e.g., expand
the condition to if (memberId == null || productIds == null ||
productIds.isEmpty()) return Set.of()); update/ add a unit test that calls the
same service method with a valid memberId and productIds == null and asserts an
empty Set is returned.
- Around line 30-33: In FavoriteService's method that currently catches
DataIntegrityViolationException and always returns false, change the catch so it
distinguishes duplicate-key/unique-constraint violations from other integrity
errors: if the exception indicates a duplicate registration (check
SQLState/constraint name/root cause message on the caught
DataIntegrityViolationException or NestedSQLException) return false; otherwise
rethrow the exception (preserving the original cause) so upstream error handling
and monitoring can detect real data/schema integrity problems; add tests for
concurrent duplicate registration returning false and for a non-duplicate
integrity violation to assert the exception is propagated.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java`:
- Around line 24-30: The cache-aside init race in resolveLikeCount can overwrite
concurrent INCR updates: change initLikeCount to perform an atomic "set if
absent" instead of a blind set by replacing RedisCacheManager.setCount usage
with a SETNX-based operation (or wrap the init in a Lua script that sets the key
only if it does not exist and returns whether it set) so that existing values
(e.g., from concurrent incrementLikeCount) are preserved; update
RedisCacheManager to expose setIfAbsent or an atomic Lua initializer and call
that from initLikeCount, and add an integration test that simulates two
concurrent cache-miss threads calling resolveLikeCount (and concurrent
incrementLikeCount) to assert no increments are lost.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java`:
- Around line 93-101: The DB and cache updates in increaseLikeCount and
decreaseLikeCount are not atomic—if
productCacheRepository.incrementLikeCount/decrementLikeCount fails after the
productRepository change, DB and Redis diverge; update these methods to catch
cache update failures and, on failure, evict the product’s like cache key (call
productCacheRepository.evictLikeCount or equivalent) so subsequent reads reload
from DB, and also log the cache error with sufficient context for monitoring;
ensure resolveLikeCount and initLikeCount behavior remains unchanged so eviction
triggers reload, and add tests simulating Redis failure to verify graceful
degradation.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java`:
- Around line 65-68: initLikeCount currently calls RedisCacheManager.setCount
which unconditionally overwrites the key and can race with concurrent
increment()/decrement() calls; add a RedisCacheManager.setCountIfAbsent(String
key, long count, long ttlSeconds) that uses opsForValue().setIfAbsent(...) (SET
NX with TTL) and change ProductCacheRepositoryImpl.initLikeCount(Long productId,
long count) to call setCountIfAbsent(KEY_PREFIX + productId + LIKES_SUFFIX,
count, TTL_SECONDS) instead of setCount; propagate and handle the boolean result
(log on false) so initialization only occurs when the key is absent.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductLikeStatsEntity.java`:
- Around line 11-19: There are two separate sources of truth for like counts
(product_like_stats.like_count represented by ProductLikeStatsEntity and
ProductEntity.likeCount), which risks inconsistency; choose a single
source-of-truth and unify reads/writes accordingly: either remove
ProductLikeStatsEntity and its table and update ProductJpaRepository (the
increment/decrement query at ProductJpaRepository) and ProductService (methods
around line ~93-101) to use only ProductEntity.likeCount, or retain
product_like_stats as the authoritative store and change ProductService and
ProductJpaRepository so all like increment/decrement and reads use
ProductLikeStatsEntity (and corresponding queries) while deleting/ignoring
ProductEntity.likeCount; also add integration tests exercising repeated like
toggles, concurrent updates, and cache-miss recovery to ensure DB-cache-response
consistency.

In
`@apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java`:
- Around line 60-76: The increment/decrement methods in RedisCacheManager
currently swallow exceptions and return null and have no TTL support, causing
DB-cache drift; update RedisCacheManager.increment and .decrement to surface
failures and accept an optional TTL (e.g., Duration ttl) or return a boolean
success indicator so callers (like ProductService) can detect failure and
trigger rollback or recovery; specifically, change
writeTemplate.opsForValue().increment(key) and .decrement(key) usage to a
variant that sets TTL when a new key is created (or call expire when increment
creates the key), throw a runtime exception on Redis operation errors instead of
returning null, and ensure initLikeCount remains compatible while ProductService
checks the success/exception to perform cache recovery/rollback logic.

In `@apps/commerce-api/src/main/java/com/loopers/support/enums/SortFilter.java`:
- Around line 11-16: Fix the non-deterministic ordering in
SortFilter.toOrderSpecifier by adding a unique secondary key to stabilize ties:
for PRICE_ASC return ordering by product.price.asc() then product.id.desc(), and
for LIKES_DESC return product.likeCount.desc() then product.id.desc() (LATEST
can remain product.id.desc()). Update the switch in SortFilter.toOrderSpecifier
to compose these two OrderSpecifier values using QProductEntity (product) so the
overall ordering is deterministic. Also add tests that place products with
identical price or likeCount on the boundary between page 0 and page 1 and
assert no duplicates or missing items across pagination.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java`:
- Around line 346-362: The test prints the entire INDEX_CANDIDATES as "최종 선택
인덱스" even when some candidates (e.g., idx_product_brand_like_price) were
rejected by measurement; fix by computing selected and rejected sets from the
measurement results instead of printing INDEX_CANDIDATES directly: use the
existing perIndex and allIndex measurement outputs to build explicit
selectedIndexes and rejectedIndexes (or extract that logic into a new method
like determineSelectedIndexes(perIndex, allIndex)), update the print block to
iterate selectedIndexes for the "최종 선택 인덱스" and rejectedIndexes for "미선택 근거",
and add a unit test for the new decision method to assert that candidates with
no improvement are placed in rejectedIndexes.
- Around line 84-118: The benchmark seeding is non-deterministic and doesn't
exercise soft-deletes or stable favorite distributions; update
ProductIndexPerformanceTest seeding to (1) create a deterministic soft-delete
ratio by setting deleted_at for a fixed subset of products (e.g., every Nth
product) when building the product INSERT loop (reference the product INSERT
block and the deleted_at column), (2) replace the RAND() favorite INSERT with a
deterministic rule (e.g., member_id and product_id modulo pattern) in the
favorite INSERT statement so favorites are repeatable and reproducible
(reference the favorite INSERT SELECT query), and (3) compute and persist
like_count in the product rows to match the deterministic favorites (reference
like_count field) and add assertions that re-seeding is idempotent and that
queries using WHERE deleted_at IS NULL and ORDER BY use the expected index.
Ensure changes are deterministic across runs and add small tests to verify
favorite distribution and soft-delete ratio remain identical on repeated
seeding.
- Around line 69-131: The setUp() method must clear existing data and guarantee
rollback/EntityManager closure on exceptions: at the start of setUp() run
DELETEs in order favorite -> product -> member -> brand within a transaction,
then perform the seed inserts; wrap the whole logic in try { begin transaction;
cleanup; seed; commit } catch(Exception e) { if (em.getTransaction().isActive())
em.getTransaction().rollback(); throw e; } finally { if (em != null &&
em.isOpen()) em.close(); } so that setUp(), the cleanup/seed SQL blocks, and
transaction handling are updated accordingly to always rollback on error and
always close the EntityManager.
- Around line 18-21: The ProductIndexPerformanceTest class currently annotated
with `@SpringBootTest` (and importing MySqlTestContainersConfig) is an expensive,
non-failing benchmark; separate it into a manual benchmark suite and replace CI
usage with small, deterministic tests: create a lightweight CI test that loads a
tiny fixed dataset and runs EXPLAIN on the target query, asserting the EXPLAIN
"key" and "type" values, and add a small integration test that explicitly drops
the expected index and verifies the query performance/regression fails (so
absence of index breaks the test). Locate the class ProductIndexPerformanceTest
and its heavy setup (`@SpringBootTest`, MySqlTestContainersConfig) to move to a
manual-only benchmark, and add two new tests: one unit/CI test that asserts
EXPLAIN key/type on the query, and one integration test that removes the index
and asserts failure, ensuring you scope test data setup/cleanup and avoid
long-running data loads in CI.
- Around line 157-185: The 3rd-phase block that creates all indexes (loop over
INDEX_CANDIDATES, executing candidate[1]) must be wrapped in a try-finally so
that after measuring via measureAllQueries(em) you explicitly DROP each created
index (execute the corresponding candidate[2] or generated drop SQL) in the
finally, and always call em.close() in that finally to guarantee EntityManager
is closed on both normal and exceptional flows; additionally add a pre-test
cleanup in setUp() to iterate INDEX_CANDIDATES and DROP any leftover candidate
indexes before the baseline measurement to ensure test isolation between runs.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java`:
- Around line 104-135: The test ProductQueryPerformanceTest leaks EntityManager
if an exception occurs and lacks assertions: wrap the EntityManager
creation/usage (the local variable em created from entityManagerFactory and all
calls to em.createNativeQuery(...).getResultList() and em.close()) in a
try-finally (or try-with-resources equivalent) to ensure em.close() always runs,
and add a deterministic performance assertion (e.g., assertTrue or
assertLessThan) against the measured elapsed value (the long elapsed from the
timed loop) with a chosen threshold so the test fails on regressions; also
include a simple assertion that the explain result (explain variable from
em.createNativeQuery("EXPLAIN ...").getResultList()) is not empty to validate
query planning.
- Around line 138-173: The test method materializedView_brandFilter_likeSort
currently only prints timings and lacks documentation, assertions, and
isolation; update it by (1) adding a method-level comment stating this is a
benchmark comparing denormalized vs materialized view query patterns for
intentional non-production query shape, (2) introduce warm-up iterations and
aggregate measurements into a List or stats object and assert against a baseline
threshold or relative comparator (e.g., average <= baselineMs or within
acceptable variance) instead of only System.out.println, and (3) ensure test
isolation by adding or using `@BeforeEach` and `@AfterEach` hooks (or transaction
rollback) to prepare/clean test data so repeated runs don't interfere and to
reduce flakiness; reference materializedView_brandFilter_likeSort, any shared
EntityManagerFactory setup, and the EXPLAIN call when capturing result details
for assertions.

In
`@apps/commerce-api/src/test/java/com/loopers/support/cache/RedisCacheManagerTest.java`:
- Around line 46-155: Tests for the count-related methods are missing; add unit
tests covering RedisCacheManager.increment, decrement, getCount, and setCount:
for increment/decrement add a success case (mock
writeTemplate.opsForValue().increment/ decrement to return a value and assert
it) and an error case (mock writeTemplate.opsForValue() to throw and assert
null), for getCount add cache hit (mock readValueOps.get to return numeric
string and assert parsed Long), cache miss (null -> empty/absent behavior), and
parse-failure (non-numeric value -> handle/assert expected behavior), and for
setCount assert writeValueOps.set is called with correct key/value/TTL and that
Redis exceptions do not throw; locate mocks and interactions around
readTemplate, writeTemplate, readValueOps, writeValueOps, and objectMapper in
RedisCacheManagerTest to implement these tests.

---

Outside diff comments:
In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/model/Product.java`:
- Around line 39-49: The public signature of Product.reconstruct was changed to
require a seventh likeCount parameter and broke existing tests; either restore a
6-arg overload or update all callers. Fix by adding a new overloaded static
method Product.reconstruct(Long id, Long brandId, String name, int price, int
stock, DisplayStatus displayStatus) that delegates to the 7-arg
Product.reconstruct(...) with likeCount = 0L, or alternatively update the three
test call sites (ProductServiceTest, ProductTest, ProductFacadeTest) to pass the
new likeCount argument and add a new test asserting default reconstruct behavior
sets likeCount to 0L. Ensure you modify the Product.reconstruct method(s) and
tests referencing reconstruct accordingly.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java`:
- Around line 33-44: The constructor projection is passing
productEntity.displayStatus (EnumPath<DisplayStatus>) into ProductItem's
constructor which expects a String, causing ExpressionException; update both
occurrences where Projections.constructor(ProductItem.class, ...) is used to
replace productEntity.displayStatus with
productEntity.displayStatus.stringValue() so the query returns a String that
matches ProductItem's 7th parameter, and add QueryDSL-based repository
integration tests asserting that list and detail queries produce valid
ProductItem instances (no ExpressionException) for both active and non-active
statuses.

---

Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java`:
- Line 16: CachedPage's items list is mutable externally; change the record to
defensively copy/wrap the list so the internal collection is immutable. Update
the record CachedPage(List<ProductItem> items, long totalElements) to assign
this.items = List.copyOf(items) (or
Collections.unmodifiableList(List.copyOf(items))) in a compact constructor (or
use a static factory) so stored items cannot be modified from outside; reference
CachedPage and ProductItem when applying the change.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java`:
- Around line 28-30: Add Javadoc to the ProductRepository methods
increaseLikeCount(Long id) and decreaseLikeCount(Long id) that explicitly states
the int return value represents the number of affected rows, what a 0 return
means (e.g. product not found / no update), and any error/exception behavior to
mirror the existing decreaseStock contract; update the method comments for
consistency with decreaseStock so callers know to handle a 0 return as a
failure/no-op.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java`:
- Around line 65-69: The current loop in ProductService
(result.getContent().forEach -> productCacheRepository.initLikeCount(...))
causes N separate Redis SETs; change ProductCacheRepository to add a batched API
(e.g., initLikeCounts(Map<Long,Long>) or initLikeCounts(List<Pair<Long,Long>>)
and implement it in RedisCacheManager using MSET or a pipelined set to perform
the writes in a single round-trip; update ProductService (where
isFirstPageLatest calls productCacheRepository.putFirstPage(...)) to build the
id->count map from result.getContent() and call the new batched method instead
of per-item initLikeCount.
- Around line 138-147: resolveLikeCount currently reads DB and then calls
productCacheRepository.initLikeCount(productId, count), which can race with
concurrent INCR/DECR and overwrite updated counts; switch to a compare-and-set
or "setCountIfAbsent" pattern in ProductCacheRepository (use a new method like
setCountIfAbsent(productId, count) instead of initLikeCount) so the cache is
only initialized when absent, and log a cache-miss event when falling back to
the DB to aid monitoring; update resolveLikeCount to call
productCacheRepository.setCountIfAbsent(productId, count) after reading
product.getLikeCount() and emit a short debug/info log indicating a cache miss
for productId.
- Around line 108-120: decreaseStockAtomic currently calls
productRepository.findById twice and can misclassify a missing product as "out
of stock"; fix by loading the product once with productRepository.findById in
decreaseStockAtomic (throw NOT_FOUND if absent), check in-memory
product.getStock() < quantity and throw BAD_REQUEST if insufficient, then call
productRepository.decreaseStock(productId, quantity), evict caches via
productCacheRepository.evict(...) / evictFirstPage(), and return the updated
product (update the in-memory product's stock or re-load only if necessary);
alternatively, if you prefer to keep optimistic DB update, replace the first
findById with existsById before calling decreaseStock and only re-query when
updatedRows==0 to distinguish NOT_FOUND vs BAD_REQUEST.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/entity/FavoriteEntity.java`:
- Around line 11-13: The new single-column index idx_favorite_product_id on the
FavoriteEntity (table "favorite") to speed up countByProductId is fine, but when
adding it in migrations for production with large data you must create the index
using MySQL online DDL options to avoid table locks; update the migration that
creates idx_favorite_product_id to use "ALTER TABLE favorite ADD INDEX
idx_favorite_product_id (productId) ALGORITHM=INPLACE, LOCK=NONE" (or the
equivalent in your migration tool), and include a rollback/remove index step and
a note to validate MySQL version (>=8.0) before applying.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/FavoriteJpaRepository.java`:
- Line 13: The repository method findByMemberIdAndProductIdIn in
FavoriteJpaRepository must guard against very large productIds lists (DB IN
limits/perf); change callers or add a new repository helper that splits
productIds into configurable chunks (e.g., <=1000) and executes repeated
findByMemberIdAndProductIdIn calls, aggregating and deduplicating results, or
implement an alternative approach (temporary table/join or batch native query)
if available; ensure chunk size is configurable and document/update usages to
call the new batched helper instead of passing an unbounded list directly to
findByMemberIdAndProductIdIn.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/impl/FavoriteRepositoryImpl.java`:
- Around line 47-52: Add a defensive guard at the start of
FavoriteRepositoryImpl.findByMemberIdAndProductIds to immediately return an
empty List when productIds is null or empty, avoiding unnecessary DB calls and
potential IN () errors; update the method that currently calls
favoriteJpaRepository.findByMemberIdAndProductIdIn to check productIds.isEmpty()
(and null) before invoking the repository, and add unit tests for both null and
empty productIds cases to assert an empty result is returned.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java`:
- Around line 87-92: toItem currently hardcodes favoriteCnt to 0L which is
intentional because ProductCacheDto lacks that field and callers call
resolveLikeCount() then withFavoriteCnt() to set the true value; add a brief
clarifying comment inside the toItem method noting this design (favorite count
is stored/updated separately for atomic INCR/DECR, initialized to 0 here and
overridden by resolveLikeCount()/withFavoriteCnt()), referencing
ProductCacheDto, toItem, resolveLikeCount, and withFavoriteCnt so future
maintainers understand the separation.

In
`@apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java`:
- Around line 30-41: 현재 RedisCacheManager의 public <T> Optional<T> get(String
key, Class<T> type) 메서드는 단일 Class 기반 역직렬화만 지원하므로 제네릭 복합 타입을 안전하게 처리하려면
TypeReference<T> 오버로드를 추가하세요: RedisCacheManager에 public <T> Optional<T>
get(String key, com.fasterxml.jackson.core.type.TypeReference<T> typeRef) 메서드를
추가하고 내부에서 readTemplate.opsForValue().get(key)로 JSON을 읽은 뒤
objectMapper.readValue(json, typeRef)로 역직렬화하며 기존 get(Class<T>)와 동일한 null 처리 및 예외
로깅(log.warn("Redis GET 실패 - key: {}", key, e)) 동작을 재사용하도록 구현하세요; 기존
get(Class<T>)는 그대로 유지해서 하위 호환성을 보장하세요.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java`:
- Around line 124-126: Replace the System.out.println calls in
ProductQueryPerformanceTest with an SLF4J Logger: add a private static final
Logger (LoggerFactory.getLogger(ProductQueryPerformanceTest.class)) to the class
and change each println (the blocks around the "=== 방식 A (비정규화) ===" output and
the other occurrences at the mentioned spots) to logger.info(...) so test output
goes through the logging framework and can be filtered by CI; ensure the
formatted messages remain the same and adjust imports to include
org.slf4j.Logger and org.slf4j.LoggerFactory.
- Around line 23-94: The test data created in setUp() (method setUp annotated
with `@BeforeAll`) is never cleaned and commits unconditionally, so add a
complementary `@AfterAll` method (e.g., tearDown) that opens an EntityManager,
begins a transaction, deletes rows from favorite, product_like_stats, product,
member, and brand (in that or safe FK order), commits and closes the EM in a
finally block; also make setUp() robust by wrapping each transactional block in
try/catch and calling em.getTransaction().rollback() on exceptions (and closing
the EntityManager in finally) so partial failures don’t leave data behind.
- Around line 66-72: The insert uses RAND() causing non-deterministic favorites;
change the WHERE clause in the em.createNativeQuery(...) favorite insert to a
deterministic predicate (e.g., replace "WHERE RAND() < 0.05" with a
modulus-based condition such as "WHERE MOD(m.id + p.id, 20) = 0" or another
fixed hash/row-number expression) so the favorite generation is reproducible
across runs; update the INSERT statement inside the createNativeQuery call that
builds favorites to use this deterministic filter.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1ddbe59b-06c7-47f0-ba17-4e8f763ec5d2

📥 Commits

Reviewing files that changed from the base of the PR and between dc49695 and 9205bec.

📒 Files selected for processing (30)
  • apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/dto/FindProductListReqDto.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/dto/FindProductResDto.java
  • apps/commerce-api/src/main/java/com/loopers/domain/favorite/repository/FavoriteRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/favorite/service/FavoriteService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/model/Product.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/model/ProductItem.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCustomRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/entity/FavoriteEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/FavoriteJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/impl/FavoriteRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheDto.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductListCacheDto.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductLikeStatsEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/ProductJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/ProductLikeStatsJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java
  • apps/commerce-api/src/main/java/com/loopers/support/enums/SortFilter.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java
  • apps/commerce-api/src/test/java/com/loopers/support/cache/RedisCacheManagerTest.java

Comment on lines +27 to +30
boolean added = favoriteService.addFavorite(command);
if (added) {
productService.increaseLikeCount(product.getId());
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# ProductService의 increaseLikeCount 구현 확인 - DB와 Redis 업데이트 순서 및 에러 처리
ast-grep --pattern $'void increaseLikeCount($_) {
  $$$
}'

# 또는 메서드 내용 확인
rg -n "increaseLikeCount" --type=java -A 15 apps/commerce-api/src/main/java/com/loopers/domain/product/service/

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2249


🏁 Script executed:

# FavoriteFacade 전체 메서드 구조 확인
rg -n "addFavorite|deleteFavorite|@Transactional" --type=java apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java -B 2 -A 8

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1420


🏁 Script executed:

# ProductService 클래스 구조 및 `@Transactional` 확인
rg -n "class ProductService|@Transactional" --type=java apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java -B 1 -A 1

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 135


🏁 Script executed:

# ProductCacheRepository의 예외 처리 방식 확인
fd "ProductCacheRepository" --type=java -exec cat {} \;

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 256


🏁 Script executed:

# ProductService의 `@Transactional` 확인
rg -n "@Transactional|public void increase|public void decrease" --type=java apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java -B 1 -A 3

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 644


🏁 Script executed:

# ProductCacheRepository 구현 찾기
find apps/commerce-api/src/main/java -name "*CacheRepository*" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 287


🏁 Script executed:

# 캐시 실패 시 예외 처리 확인
rg -n "try|catch|exception|throw" --type=java apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 242


🏁 Script executed:

# ProductCacheRepository 인터페이스 확인
cat -n apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1033


🏁 Script executed:

# ProductCacheRepositoryImpl 구현 확인
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3902


🏁 Script executed:

# RedisCacheManager 찾기 및 increment/decrement 메서드 확인
find apps/commerce-api/src/main/java -name "RedisCacheManager.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 165


🏁 Script executed:

# increment/decrement 메서드 구현 확인
rg -n "increment|decrement" apps/commerce-api/src/main/java -A 5 -B 1 | head -100

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 6103


Redis 캐시 실패로 인한 DB-캐시 불일치 처리 필요

productService.increaseLikeCount에서 DB 업데이트는 트랜잭션 범위 내에서 성공하지만, Redis 캐시 업데이트가 실패하면 예외 대신 경고만 로깅하고 계속 진행된다. 이로 인해 DB에는 좋아요 수가 증가했으나 캐시는 이전 값으로 유지되어 조회 시 부정확한 데이터를 반환하는 운영 문제가 발생한다.

개선 방안:

  • 캐시 실패를 비즈니스 로직 레벨에서 감지하고 처리 또는 재시도 로직 추가
  • 또는 Redis 장애 시에도 DB를 신뢰할 수 있도록 캐시 동기화 배치 작업 구현
  • 모니터링: Redis 실패 로그를 관찰하되, 자동 복구 메커니즘 부재 시 알림 규칙 필수
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java`
around lines 27 - 30, The current flow in FavoriteFacade (after calling
favoriteService.addFavorite(command)) calls
productService.increaseLikeCount(product.getId()) but swallows Redis cache
failures inside that call; change the logic so FavoriteFacade detects
cache-update failures from productService.increaseLikeCount (e.g., propagate a
specific exception or return a result/status), and handle it by either
triggering a retry path or marking the operation as partially failed and
enqueuing a background/sync job; specifically update
productService.increaseLikeCount to surface cache errors (throw
CacheUpdateException or return a boolean/status), then in FavoriteFacade check
that result and on failure: (a) attempt a limited retry, or (b) record an async
reconciliation task (or flag) to resync cache and emit an alert/event so the DB
remains authoritative until cache is reconciled.

Comment on lines +5 to +9
public record FindProductListReqDto(
String loginId,
String password,
Long brandId,
SortFilter sortFilter
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Java records, does the compiler-generated toString() include all record component values by default?

💡 Result:

Yes. If you don’t declare your own toString(), the Java compiler synthesizes one for a record that includes the record’s name and the string representation of all record components (with their names) by default. (docs.oracle.com)

Citations:


Record에 평문 비밀번호를 포함하면 안 된다.

Java record는 컴파일러가 자동으로 모든 컴포넌트를 포함하는 toString()을 생성한다. 따라서 이 DTO가 로그, 예외 스택 트레이스, AOP 인터셉터를 거치면 비밀번호가 평문으로 기록된다. 운영 환경에서 로그에 수집된 자격 증명은 회수 불가능하며 보안 사고 범위를 키운다.

개선 방안:

  • 인증 로직을 컨트롤러/보안 계층에서 처리하고, 여기에는 memberId나 인증된 principal만 전달한다.
  • 임시 조치로 toString() 메서드를 override하여 민감 정보를 마스킹한다.

추가 검증:

  • toString() 호출 결과에 비밀번호가 포함되지 않음을 테스트한다.
  • 비밀번호를 제거한 상태에서 상품 목록 조회가 정상 동작함을 통합 테스트로 검증한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/dto/FindProductListReqDto.java`
around lines 5 - 9, FindProductListReqDto currently holds a plain-text password
field which will be included in the compiler-generated toString(); remove
sensitive data by eliminating the password component from the record signature
(pass only an authenticated memberId/principal into controllers) or, as a
short-term mitigation, override FindProductListReqDto#toString() to mask or omit
the password field so it never appears in logs; update callers of
FindProductListReqDto to stop supplying raw credentials and add tests asserting
toString() does not contain the password and that product listing still works
when password is removed.

Comment on lines +157 to +185
// CREATE
em.getTransaction().begin();
em.createNativeQuery(createSql).executeUpdate();
em.getTransaction().commit();

// 측정
List<QueryResult> results = measureAllQueries(em);
perIndexResults.put(indexName, results);

// DROP (다음 인덱스 개별 테스트를 위해 원복)
em.getTransaction().begin();
em.createNativeQuery(dropSql).executeUpdate();
em.getTransaction().commit();
}

// ═══ 3단계: 전체 인덱스 동시 적용 ═══
System.out.println("\n========================================");
System.out.println(" [ALL INDEXES] 전체 인덱스 적용 후 측정");
System.out.println("========================================");

em.getTransaction().begin();
for (String[] candidate : INDEX_CANDIDATES) {
em.createNativeQuery(candidate[1]).executeUpdate();
}
em.getTransaction().commit();

List<QueryResult> allIndexResults = measureAllQueries(em);

em.close();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Testcontainers DB lifecycle =="
fd 'MySqlTestContainersConfig.java$' . -x sed -n '1,220p' {}

echo
echo "== ProductEntity / migration index declarations =="
fd 'ProductEntity.java$' . -x sed -n '1,220p' {}
rg -n -C2 --glob '*.java' --glob '*.sql' '@Index\(|indexes\s*=|idx_product_|idx_favorite_'

echo
echo "== Hibernate schema generation settings =="
rg -n -C2 --glob '*application*.yml' --glob '*application*.yaml' --glob '*application*.properties' 'ddl-auto|hibernate\.hbm2ddl|spring\.jpa\.hibernate'

echo
echo "== Benchmark setup / cleanup path =="
sed -n '133,190p' apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 11269


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Full ProductIndexPerformanceTest method structure =="
wc -l apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java
sed -n '90,250p' apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java

echo
echo "== Checking for cleanup/teardown patterns =="
rg -n 'tearDown|@After|finally|em\.close|em\.clear' apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java

echo
echo "== Hibernate DDL configuration =="
find . -path '*/test/resources/*' -name 'application*.yml' -o -name 'application*.yaml' -o -name 'application*.properties' | head -20 | xargs -I {} sh -c 'echo "=== {} ===" && cat {}'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 6757


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Main Hibernate DDL settings =="
find . -path '*/main/resources/*' \( -name 'application*.yml' -o -name 'application*.yaml' -o -name 'application*.properties' \) | xargs grep -l 'ddl-auto\|hbm2ddl\|jpa.hibernate' 2>/dev/null | head -5 | xargs -I {} sh -c 'echo "=== {} ===" && cat {}'

echo
echo "== ProductIndexPerformanceTest class-level annotations and imports =="
head -40 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java

echo
echo "== Test method signatures and `@BeforeEach/`@AfterEach patterns =="
rg -n '@Test|@BeforeEach|@AfterEach|@Before|@After|class ProductIndexPerformanceTest' apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 120


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== All application configuration files =="
find . -type f -name 'application*' 2>/dev/null | head -20

echo
echo "== ProductIndexPerformanceTest class declaration and setup =="
sed -n '1,80p' apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java

echo
echo "== Try-finally patterns in indexPerformanceBenchmark =="
sed -n '133,200p' apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java | cat -n

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 7404


3단계에서 생성한 모든 인덱스를 종료 시 정리하고, 정상/예외 경로 모두에서 EntityManager 종료를 보장하다.

운영 관점에서 3단계(전체 인덱스 동시 적용)에서 6개 인덱스를 생성한 후 정리하지 않으므로, @TestInstance(PER_CLASS)로 인해 같은 스키마를 재사용하는 다음 테스트 실행의 1단계 baseline이 즉시 오염된다. 마찬가지로 예외 발생 시 EntityManager.close()가 보장되지 않아 리소스 누수가 발생할 수 있다.

수정안: try-finally 블록으로 3단계 이후 모든 후보 인덱스를 명시적으로 DROP하고, finally 블록에서 EntityManager 종료를 보장하며, 추가로 setUp() 메서드에서 벤치마크 시작 전에도 후보 인덱스 존재 여부를 확인하여 사전 정리한다.

추가 테스트: 이 클래스를 2회 연속 실행했을 때, 두 번째 실행의 1단계 baseline 측정 결과(특히 각 쿼리의 keyUsed 값)가 첫 번째 실행과 동일한지 확인하여 격리 수준을 검증한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java`
around lines 157 - 185, The 3rd-phase block that creates all indexes (loop over
INDEX_CANDIDATES, executing candidate[1]) must be wrapped in a try-finally so
that after measuring via measureAllQueries(em) you explicitly DROP each created
index (execute the corresponding candidate[2] or generated drop SQL) in the
finally, and always call em.close() in that finally to guarantee EntityManager
is closed on both normal and exceptional flows; additionally add a pre-test
cleanup in setUp() to iterate INDEX_CANDIDATES and DROP any leftover candidate
indexes before the baseline measurement to ensure test isolation between runs.

Comment on lines +346 to +362
System.out.println(" ════════════════════════════════════════════════════════════════════");
System.out.println(" 최종 선택 인덱스:");
for (String[] candidate : INDEX_CANDIDATES) {
System.out.printf(" ✓ %s (%s)%n", candidate[0], candidate[3]);
}
System.out.println();
System.out.println(" 선택 근거:");
System.out.println(" 1. idx_product_price: 전체 가격순 정렬 시 filesort 제거");
System.out.println(" 2. idx_product_like_count: 전체 좋아요순 정렬 시 filesort 제거");
System.out.println(" 3. idx_product_brand_price: 브랜드 필터 + 가격순 정렬을 복합 인덱스로 커버");
System.out.println(" 4. idx_product_brand_like: 브랜드 필터 + 좋아요순 정렬을 복합 인덱스로 커버");
System.out.println(" 5. idx_favorite_product_id: countByProductId 풀스캔 → ref 스캔으로 개선");
System.out.println();
System.out.println(" 미선택 근거:");
System.out.println(" - (brand_id, id) 복합 인덱스: brand_id 필터 후 PK 역순 스캔 가능, 복합 인덱스 prefix로도 커버");
System.out.println(" - (id) 단독 인덱스: PK 클러스터드 인덱스가 이미 존재");
System.out.println(" ════════════════════════════════════════════════════════════════════");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

결론 출력이 측정 결과와 동기화돼 있지 않다.

운영 관점에서는 INDEX_CANDIDATES 전체를 그대로 최종 선택 인덱스로 출력해서, 실제로는 제외 대상이어야 할 idx_product_brand_like_price까지 채택된 것처럼 로그에 남는다. 수정안은 선택/미선택 목록을 perIndexallIndex의 측정 결과에서 계산하거나, 최소한 명시적인 selectedIndexesrejectedIndexes를 분리하는 것이다. 추가 테스트로는 결론 생성 로직을 별도 메서드로 분리한 뒤, 개선이 없는 후보가 선택 목록에 노출되지 않는지 단위 테스트해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java`
around lines 346 - 362, The test prints the entire INDEX_CANDIDATES as "최종 선택
인덱스" even when some candidates (e.g., idx_product_brand_like_price) were
rejected by measurement; fix by computing selected and rejected sets from the
measurement results instead of printing INDEX_CANDIDATES directly: use the
existing perIndex and allIndex measurement outputs to build explicit
selectedIndexes and rejectedIndexes (or extract that logic into a new method
like determineSelectedIndexes(perIndex, allIndex)), update the print block to
iterate selectedIndexes for the "최종 선택 인덱스" and rejectedIndexes for "미선택 근거",
and add a unit test for the new decision method to assert that candidates with
no improvement are placed in rejectedIndexes.

Comment on lines +104 to +135
EntityManager em = entityManagerFactory.createEntityManager();

em.createNativeQuery(
"SELECT p.id, p.name, p.price, p.stock, p.like_count " +
"FROM product p " +
"WHERE p.brand_id = 1 AND p.deleted_at IS NULL " +
"ORDER BY p.like_count DESC LIMIT 20")
.getResultList();

long start = System.currentTimeMillis();
for (int i = 0; i < 10; i++) {
em.createNativeQuery(
"SELECT p.id, p.name, p.price, p.stock, p.like_count " +
"FROM product p " +
"WHERE p.brand_id = 1 AND p.deleted_at IS NULL " +
"ORDER BY p.like_count DESC LIMIT 20")
.getResultList();
}
long elapsed = System.currentTimeMillis() - start;

System.out.println("=== 방식 A (비정규화) ===");
System.out.println("10회 실행 총 시간: " + elapsed + "ms");
System.out.println("평균: " + (elapsed / 10.0) + "ms");

var explain = em.createNativeQuery(
"EXPLAIN SELECT p.id, p.name, p.price, p.stock, p.like_count " +
"FROM product p " +
"WHERE p.brand_id = 1 AND p.deleted_at IS NULL " +
"ORDER BY p.like_count DESC LIMIT 20")
.getResultList();
System.out.println("EXPLAIN: " + explain);
em.close();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

EntityManager 리소스 누수 위험 및 assertion 부재

두 가지 문제가 있다:

  1. 리소스 누수: 쿼리 실행 중 예외 발생 시 em.close()가 호출되지 않는다. try-finally 또는 try-with-resources 패턴이 필요하다.

  2. Assertion 부재: 성능 측정값을 출력만 하고 검증하지 않는다. 성능 회귀를 감지하려면 임계값 기반 assertion이 필요하다. 현재 상태로는 인덱스 적용 후 성능이 악화되어도 테스트가 통과한다.

🔧 리소스 관리 및 assertion 추가 예시
         `@Test`
         void denormalization_brandFilter_likeSort() {
-            EntityManager em = entityManagerFactory.createEntityManager();
-
-            em.createNativeQuery(
-                    "SELECT p.id, p.name, p.price, p.stock, p.like_count " +
-                    "FROM product p " +
-                    "WHERE p.brand_id = 1 AND p.deleted_at IS NULL " +
-                    "ORDER BY p.like_count DESC LIMIT 20")
-                .getResultList();
-
-            long start = System.currentTimeMillis();
-            for (int i = 0; i < 10; i++) {
+            EntityManager em = entityManagerFactory.createEntityManager();
+            try {
                 em.createNativeQuery(
                         "SELECT p.id, p.name, p.price, p.stock, p.like_count " +
                         "FROM product p " +
                         "WHERE p.brand_id = 1 AND p.deleted_at IS NULL " +
                         "ORDER BY p.like_count DESC LIMIT 20")
                     .getResultList();
-            }
-            long elapsed = System.currentTimeMillis() - start;
 
-            System.out.println("=== 방식 A (비정규화) ===");
-            System.out.println("10회 실행 총 시간: " + elapsed + "ms");
-            System.out.println("평균: " + (elapsed / 10.0) + "ms");
-
-            var explain = em.createNativeQuery(
-                    "EXPLAIN SELECT p.id, p.name, p.price, p.stock, p.like_count " +
-                    "FROM product p " +
-                    "WHERE p.brand_id = 1 AND p.deleted_at IS NULL " +
-                    "ORDER BY p.like_count DESC LIMIT 20")
-                .getResultList();
-            System.out.println("EXPLAIN: " + explain);
-            em.close();
+                long start = System.currentTimeMillis();
+                for (int i = 0; i < 10; i++) {
+                    em.createNativeQuery(
+                            "SELECT p.id, p.name, p.price, p.stock, p.like_count " +
+                            "FROM product p " +
+                            "WHERE p.brand_id = 1 AND p.deleted_at IS NULL " +
+                            "ORDER BY p.like_count DESC LIMIT 20")
+                        .getResultList();
+                }
+                long elapsed = System.currentTimeMillis() - start;
+                double avgMs = elapsed / 10.0;
+
+                System.out.println("=== 방식 A (비정규화) ===");
+                System.out.println("10회 실행 총 시간: " + elapsed + "ms");
+                System.out.println("평균: " + avgMs + "ms");
+
+                // 성능 회귀 방지용 임계값 (인덱스 적용 시 평균 50ms 이하 기대)
+                assertThat(avgMs).isLessThan(100.0);
+            } finally {
+                em.close();
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java`
around lines 104 - 135, The test ProductQueryPerformanceTest leaks EntityManager
if an exception occurs and lacks assertions: wrap the EntityManager
creation/usage (the local variable em created from entityManagerFactory and all
calls to em.createNativeQuery(...).getResultList() and em.close()) in a
try-finally (or try-with-resources equivalent) to ensure em.close() always runs,
and add a deterministic performance assertion (e.g., assertTrue or
assertLessThan) against the measured elapsed value (the long elapsed from the
timed loop) with a chosen threshold so the test fails on regressions; also
include a simple assertion that the explain result (explain variable from
em.createNativeQuery("EXPLAIN ...").getResultList()) is not empty to validate
query planning.

Comment on lines +138 to +173
@DisplayName("방식 B: MaterializedView - 브랜드 필터 + 좋아요 순 정렬")
@Test
void materializedView_brandFilter_likeSort() {
EntityManager em = entityManagerFactory.createEntityManager();

em.createNativeQuery(
"SELECT p.id, p.name, p.price, p.stock, s.like_count " +
"FROM product p JOIN product_like_stats s ON p.id = s.product_id " +
"WHERE p.brand_id = 1 AND p.deleted_at IS NULL " +
"ORDER BY s.like_count DESC LIMIT 20")
.getResultList();

long start = System.currentTimeMillis();
for (int i = 0; i < 10; i++) {
em.createNativeQuery(
"SELECT p.id, p.name, p.price, p.stock, s.like_count " +
"FROM product p JOIN product_like_stats s ON p.id = s.product_id " +
"WHERE p.brand_id = 1 AND p.deleted_at IS NULL " +
"ORDER BY s.like_count DESC LIMIT 20")
.getResultList();
}
long elapsed = System.currentTimeMillis() - start;

System.out.println("=== 방식 B (MaterializedView) ===");
System.out.println("10회 실행 총 시간: " + elapsed + "ms");
System.out.println("평균: " + (elapsed / 10.0) + "ms");

var explain = em.createNativeQuery(
"EXPLAIN SELECT p.id, p.name, p.price, p.stock, s.like_count " +
"FROM product p JOIN product_like_stats s ON p.id = s.product_id " +
"WHERE p.brand_id = 1 AND p.deleted_at IS NULL " +
"ORDER BY s.like_count DESC LIMIT 20")
.getResultList();
System.out.println("EXPLAIN: " + explain);
em.close();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: product_like_stats JOIN 사용 여부 확인

# 프로덕션 코드에서 product_like_stats JOIN 패턴 검색
rg -n "product_like_stats" --type=java -g '!*Test*' apps/commerce-api/src/main/

# ProductCustomRepositoryImpl에서 실제 쿼리 패턴 확인
ast-grep --pattern $'queryFactory
  $$$
  .from(productEntity)
  $$$
  .innerJoin($$$)
  $$$'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 225


🏁 Script executed:

# ProductCustomRepositoryImpl 파일 위치 찾기
find apps/commerce-api -name "*ProductCustomRepositoryImpl*" -o -name "*ProductRepository*" | grep -v Test | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 612


🏁 Script executed:

# ProductEntity에서 likeCount 필드 확인
find apps/commerce-api -name "ProductEntity.java" | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 177


🏁 Script executed:

# ProductCustomRepositoryImpl 파일 크기 확인
wc -l apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 203


🏁 Script executed:

# ProductEntity 파일 크기 확인
wc -l apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductEntity.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 180


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4347


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductEntity.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3139


🏁 Script executed:

wc -l apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 180


🏁 Script executed:

cat -n apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 10965


테스트 의도는 명시되어 있으나, 벤치마크 성격을 고려한 구조 개선이 필요하다

클래스 주석에서 "비정규화 vs MaterializedView vs 정규화 성능 비교"로 의도가 명시되어 있으므로, 방식 B가 프로덕션과 다른 것은 의도된 대안 비교다. 따라서 이 부분은 문제 없다.

다만 테스트 관점에서 다음을 개선해야 한다:

  • 각 메서드에 명시적 주석 추가: "벤치마크 목적으로 프로덕션 구현과 상이한 쿼리 패턴을 의도적으로 비교합니다" 명시
  • 검증 로직 추가: 현재 System.out.println만 있으므로, 최소한 성능 기준값(baseline) 검증 또는 결과 캡처 로직 추가
  • 테스트 격리 및 정리 전략: @BeforeEach/@AfterEach에서 테스트 데이터 정리하거나, 각 실행 간 영향도를 최소화하는 구조로 변경
  • 플래키 가능성 대비: 벤치마크는 시스템 상태에 따라 결과가 불안정할 수 있으므로, 반복 실행 또는 통계 기반 분석 로직 고려
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java`
around lines 138 - 173, The test method materializedView_brandFilter_likeSort
currently only prints timings and lacks documentation, assertions, and
isolation; update it by (1) adding a method-level comment stating this is a
benchmark comparing denormalized vs materialized view query patterns for
intentional non-production query shape, (2) introduce warm-up iterations and
aggregate measurements into a List or stats object and assert against a baseline
threshold or relative comparator (e.g., average <= baselineMs or within
acceptable variance) instead of only System.out.println, and (3) ensure test
isolation by adding or using `@BeforeEach` and `@AfterEach` hooks (or transaction
rollback) to prepare/clean test data so repeated runs don't interfere and to
reduce flakiness; reference materializedView_brandFilter_likeSort, any shared
EntityManagerFactory setup, and the EXPLAIN call when capturing result details
for assertions.

Comment on lines +46 to +155
@DisplayName("GET 연산")
@Nested
class Get {

@DisplayName("캐시에 데이터가 있으면 역직렬화하여 반환한다")
@Test
void returnsDeserialized_whenCacheHit() throws Exception {
// arrange
String json = "{\"id\":1}";
TestDto expected = new TestDto(1L, "test");

when(readTemplate.opsForValue()).thenReturn(readValueOps);
when(readValueOps.get("key:1")).thenReturn(json);
when(objectMapper.readValue(json, TestDto.class)).thenReturn(expected);

// act
Optional<TestDto> result = redisCacheManager.get("key:1", TestDto.class);

// assert
assertThat(result).isPresent();
assertThat(result.get().id()).isEqualTo(1L);
}

@DisplayName("캐시에 데이터가 없으면 빈 Optional을 반환한다")
@Test
void returnsEmpty_whenCacheMiss() {
// arrange
when(readTemplate.opsForValue()).thenReturn(readValueOps);
when(readValueOps.get("key:1")).thenReturn(null);

// act
Optional<TestDto> result = redisCacheManager.get("key:1", TestDto.class);

// assert
assertThat(result).isEmpty();
}

@DisplayName("Redis 장애 시 빈 Optional을 반환한다")
@Test
void returnsEmpty_whenRedisError() {
// arrange
when(readTemplate.opsForValue()).thenThrow(new RuntimeException("Redis 연결 실패"));

// act
Optional<TestDto> result = redisCacheManager.get("key:1", TestDto.class);

// assert
assertThat(result).isEmpty();
}
}

@DisplayName("PUT 연산")
@Nested
class Put {

@DisplayName("JSON으로 직렬화하여 TTL과 함께 저장한다")
@Test
void savesWithTtl() throws Exception {
// arrange
TestDto dto = new TestDto(1L, "test");
String json = "{\"id\":1}";

when(objectMapper.writeValueAsString(dto)).thenReturn(json);
when(writeTemplate.opsForValue()).thenReturn(writeValueOps);

// act
redisCacheManager.put("key:1", dto, 3600);

// assert
verify(writeValueOps).set("key:1", json, 3600, TimeUnit.SECONDS);
}

@DisplayName("Redis 장애 시 예외를 던지지 않는다")
@Test
void doesNotThrow_whenRedisError() throws Exception {
// arrange
TestDto dto = new TestDto(1L, "test");
when(objectMapper.writeValueAsString(dto)).thenReturn("{\"id\":1}");
when(writeTemplate.opsForValue()).thenThrow(new RuntimeException("Redis 연결 실패"));

// act & assert (no exception)
redisCacheManager.put("key:1", dto, 3600);
}
}

@DisplayName("EVICT 연산")
@Nested
class Evict {

@DisplayName("키를 삭제한다")
@Test
void deletesKey() {
// act
redisCacheManager.evict("key:1");

// assert
verify(writeTemplate).delete("key:1");
}

@DisplayName("Redis 장애 시 예외를 던지지 않는다")
@Test
void doesNotThrow_whenRedisError() {
// arrange
when(writeTemplate.delete("key:1")).thenThrow(new RuntimeException("Redis 연결 실패"));

// act & assert (no exception)
redisCacheManager.evict("key:1");
}
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

increment, decrement, getCount, setCount 메서드 테스트 누락

RedisCacheManager의 카운트 관련 메서드들이 테스트되지 않았다. 특히 increment/decrement는 좋아요 기능의 핵심이므로 다음 케이스를 추가해야 한다:

  • 정상 증가/감소 동작
  • Redis 장애 시 null 반환 확인
  • getCount 캐시 히트/미스/파싱 실패 케이스
💚 테스트 추가 예시
`@DisplayName`("INCREMENT 연산")
`@Nested`
class Increment {

    `@DisplayName`("카운트를 증가시키고 결과를 반환한다")
    `@Test`
    void returnsIncrementedValue() {
        when(writeTemplate.opsForValue()).thenReturn(writeValueOps);
        when(writeValueOps.increment("count:1")).thenReturn(42L);

        Long result = redisCacheManager.increment("count:1");

        assertThat(result).isEqualTo(42L);
    }

    `@DisplayName`("Redis 장애 시 null을 반환한다")
    `@Test`
    void returnsNull_whenRedisError() {
        when(writeTemplate.opsForValue()).thenThrow(new RuntimeException("Redis 연결 실패"));

        Long result = redisCacheManager.increment("count:1");

        assertThat(result).isNull();
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/support/cache/RedisCacheManagerTest.java`
around lines 46 - 155, Tests for the count-related methods are missing; add unit
tests covering RedisCacheManager.increment, decrement, getCount, and setCount:
for increment/decrement add a success case (mock
writeTemplate.opsForValue().increment/ decrement to return a value and assert
it) and an error case (mock writeTemplate.opsForValue() to throw and assert
null), for getCount add cache hit (mock readValueOps.get to return numeric
string and assert parsed Long), cache miss (null -> empty/absent behavior), and
parse-failure (non-numeric value -> handle/assert expected behavior), and for
setCount assert writeValueOps.set is called with correct key/value/TTL and that
Redis exceptions do not throw; locate mocks and interactions around
readTemplate, writeTemplate, readValueOps, writeValueOps, and objectMapper in
RedisCacheManagerTest to implement these tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants